#!usr/bin/python

UPDATE = False

from tut_parser import *

############################# User Interface ##################################

from Tkinter import *
from tkMessageBox import *
from tkFileDialog import *
import tkFont
from subprocess import *
from threading import Thread, Lock, RLock, current_thread
import os, os.path
import hashlib
import webbrowser
import updater

CODE_FONT = "Courier"
CODE_FONT_SIZE = 10
HELP_FONT = "Helvetica"
HELP_FONT_SIZE = 10

class ScrollingText(object):
	def __init__(self, master, title, width=None, height=None, monospace=True, editable=False):
		self.editable = editable

		self.frame = text_frame = Frame(master)

		label = Label(text_frame, text=title, justify=LEFT, anchor=W)
		label.pack(side=TOP, fill=X)

		if monospace:
			console_font = tkFont.Font(family=CODE_FONT, size=CODE_FONT_SIZE, weight=tkFont.BOLD)
		else:
			console_font = tkFont.Font(family=HELP_FONT, size=HELP_FONT_SIZE)

		self.console = Text(text_frame, width=width or 10, height=height or 10, font=console_font, tabs=(console_font.measure('M')*4), state=NORMAL if editable else DISABLED)
		self.console.pack(side=LEFT, fill=BOTH, expand=1)
		if not monospace:
			self.console.configure(wrap=WORD)

		self.scrollbar = Scrollbar(text_frame)
		self.console.configure(yscrollcommand=self.scrollbar.set)
		self.scrollbar.pack(side=RIGHT, fill=Y)
		self.scrollbar.configure(command=self.console.yview)

	def add_text(self, text, color=None, pos=END):
		self.console.config(state=NORMAL)
		if color:
			# TODO: change 'color' to 'tag'
			#self.console.tag_config(color, foreground=color)
			self.console.insert(pos, text, color)
		else:
			self.console.insert(pos, text)
		if not self.editable:
			self.console.config(state=DISABLED)
		self.console.yview_moveto(1.0)

	def write(self, text):
		self.add_text(text) # TODO: dedup
	
	def clear_text(self):
		self.console.config(state=NORMAL)
		self.console.delete(1.0, END)
		if not self.editable:
			self.console.config(state=DISABLED)
	
	def set_text(self, text):
		self.clear_text()
		self.add_text(text)

	def set_editable(self, editable):
		if self.editable == editable:
			return
		self.editable = editable
		self.console.config(state = NORMAL if editable else DISABLED)

	def get_text(self):
		return self.console.get(1.0, END)
	
	def scroll_to_line(self, line=0):
		self.console.yview_moveto(float(line)/(self.get_text().count('\n')+1))
	def highlight_line(self, start, end=None, color = 'yellow'):
		# TODO: do this in a better way...
		self.console.tag_config(color, background=color)
		if end is None:
			end = start
		self.console.tag_add(color, '%d.0' % start, '%d.0' % (end + 1))
	def highlight_clear(self, color='yellow'):
		pass
	def highlight(self, pattern, color='yellow'):
		self.highlight_match(pattern, tag_and_trim_operator(make_color_tag(self.console, color)))

	def highlight_match(self, pattern, operator=None, begin="1.0", stop="end", regexp=True):
		begin = self.console.index(begin)
		stop = self.console.index(stop)
		self.console.mark_set("search_begin", begin)
		self.console.mark_set("search_stop", begin)
		self.console.mark_set("search_end", stop)

		result_length = IntVar()
		while True:
			result_start = self.console.search(pattern, "search_stop", "search_end", count=result_length, regexp=regexp)
			if result_start == "":
				break
			self.console.mark_set("search_begin", result_start)
			self.console.mark_set("search_stop", "%s+%sc" % (result_start, result_length.get()))
			if operator:
				operator(self)

def tag_and_trim_operator(tk_tag, trim=0):
	def trimmer(editor):
		editor.console.config(state=NORMAL)
		editor.console.tag_add(tk_tag, "search_begin", "search_stop")
		editor.console.delete("search_begin", "search_begin+%sc" % trim)
		editor.console.delete("search_stop-%sc" % trim, "search_stop")
		if not editor.editable:
			editor.console.config(state=DISABLED)
	return trimmer

def make_color_tag(text, color):
	tag_name = 'color' + color.replace('#', '')
	text.tag_config(tag_name, background=color)
	return tag_name

def background_operator(editor):
	text = editor.console
	text.config(state=NORMAL)
	color, contents = editor.console.get("search_begin", "search_stop").strip('&').split('&', 1)
	tag_name = make_color_tag(text, color)
	text.delete("search_begin", "search_stop")
	text.insert("search_stop", contents, tag_name)
	if not editor.editable:
		editor.console.config(state=DISABLED)

# TODO: move this variable elsewhere
images = []
def image_operator(editor):
	editor.console.config(state=NORMAL)
	img = editor.console.get("search_begin", "search_stop").strip('[]')
	editor.console.delete("search_begin", "search_stop")
	photo_image = PhotoImage(file=os.path.abspath(img))
	images.append(photo_image)
	editor.console.image_create("search_begin", image=photo_image)
	if not editor.editable:
		editor.console.config(state=DISABLED)

def hyperlink_operator(editor):
	text = editor.console
	editor.console.config(state=NORMAL)
	url, title = editor.console.get("search_begin", "search_stop").strip('{}').split('|')
	editor.console.delete("search_begin", "search_stop")
	tag_name = 'link' + hashlib.sha224(url).hexdigest()
	text.tag_config(tag_name, underline=1, foreground="blue")
	text.insert("search_stop", title, tag_name)
	def mouse_over(event): text.config(cursor='hand2')
	def mouse_out(event): text.config(cursor='')
	def mouse_click(event): webbrowser.open(url)
	text.tag_bind(tag_name, '<Enter>', mouse_over)
	text.tag_bind(tag_name, '<Leave>', mouse_out)
	text.tag_bind(tag_name, '<Button-1>', mouse_click)
	if not editor.editable:
		editor.console.config(state=DISABLED)

class Guide(ScrollingText):
	def __init__(self, master):
		super(Guide, self).__init__(master, 'Directions', width=30, monospace=False, editable=False)
		# TODO: specify font in one place
		self.console.tag_config('bold', font=(HELP_FONT, HELP_FONT_SIZE, 'bold'))
		self.console.tag_config('italic', font=('Helvetica', HELP_FONT_SIZE, 'italic'))
		self.console.tag_config('code', font=(CODE_FONT, HELP_FONT_SIZE))
		self.console.tag_config('highlight', background='yellow')
	def add_text(self, text, color=None):
		super(Guide, self).add_text(text, color)
		self.highlight_match(r'\*\*.*?\*\*', tag_and_trim_operator('bold', 2))
		self.highlight_match(r'//.*?//', tag_and_trim_operator('italic', 2))
		self.highlight_match(r'@@.*?@@', tag_and_trim_operator('code', 2))
		self.highlight_match(r'\$\$.*?\$\$', tag_and_trim_operator('highlight', 2))
		self.highlight_match(r'\[\[.*?\]\]', image_operator)
		self.highlight_match(r'\{\{.*?\}\}', hyperlink_operator)
		self.highlight_match(r'&&.*?&.*?&&', background_operator)

class Console(ScrollingText):
	def __init__(self, master):
		super(Console, self).__init__(master, 'Python Console', height=5)
		self.master = master # TODO: may not be needed.
		self.console.config(background = 'black', insertbackground = 'white', foreground = 'white')
		self.console.bind('<Key>', self.restrain_cursor)
		self.console.bind('<Button>', self.restrain_cursor)
		self.console.bind('<Return>', self.line_entered)

		self.console.mark_set('prompt', INSERT)
		self.console.mark_gravity('prompt', LEFT)
		self.console.tag_config('highlight', background='yellow')

		self.lock = Lock()

		self.line_queue = Queue()
		self.print_queue = Queue()
		self.set_editable(False)

		self.console.bind('<Destroy>', self.cancel_update_text)
		self.update_text()

	def cancel_update_text(self, event):
		self.console.after_cancel(self.after_id)
	
	def update_text(self):
		# Print lines from the queue
		# TODO: are we locked?
		if self.lock.acquire(False):
			try:
				while True:
					text = self.print_queue.get_nowait()
					#sys.stderr.write('Dequeueing string %s\n' % text)
					super(Console, self).add_text(text)
					self.print_queue.task_done()
			except Empty:
				pass
			finally:
				self.lock.release()
		self.after_id = self.console.after(100, self.update_text)

	def readline(self):
		"""
		Reads a line from a console.
		Must be called from a blockable thread.
		"""
		# TODO: handle these marks better...
		try:
			# Wait for things to finish printing
			#sys.stderr.write('Waiting for printing to finish...\n')
			self.print_queue.join()
			# TODO: a line might get added for printing right here.
			# Semaphore semantics might be better...
			#sys.stderr.write('Getting readline lock...\n')
			self.lock.acquire()
			self.set_editable(True)
			line = self.line_queue.get()
			return line
		finally:
			self.set_editable(False)
			self.lock.release()

	def line_entered(self, data):
		"Puts the line into the blocking queue."
		if not self.editable:
			return
		
		self.console.mark_set(INSERT, END)
		line = self.console.get('prompt', INSERT)
		self.master.after(0, self.console.mark_set, 'prompt', INSERT)
		if line:
			if line[0] == '\n':
				line = line[1:]
			self.line_queue.put(line + '\n')
	
	def restrain_cursor(self, event):
		if not self.editable:
			return
		"Makes sure the cursor stays beyond the prompt."
		def restrain_later():
			cursor_line, cursor_column = map(int, self.console.index(INSERT).split('.'))
			prompt_line, prompt_column = map(int, self.console.index('prompt').split('.'))
			if cursor_line < prompt_line or \
				(cursor_line == prompt_line and cursor_column < prompt_column):
				self.console.mark_set(INSERT, 'prompt')
		self.master.after(0, restrain_later)
	
	def add_text(self, text):
		#sys.stderr.write('Putting text %s\n' % text)
		self.print_queue.put(text)

	def set_editable(self, editable):
		"You shouldn't call this method directly on a Console."
		super(Console, self).set_editable(editable)

		# Keep the cursor beyond the end of the text.
		def set_marker():
			self.console.mark_set(INSERT, END)
			self.console.mark_set('prompt', INSERT)
		if editable:
			self.console.after(0, set_marker)

class CodeEditor(ScrollingText):
	def __init__(self, master):
		super(CodeEditor, self).__init__(master, 'Your Code')
		self.console.configure(maxundo = 100, undo = 100)
		self.console.tag_config('highlight', background='yellow')
	
	def add_text(self, text, color=None, pos=END):
		super(CodeEditor, self).add_text(text, color, pos)
		self.highlight_match(r'&&.*?&.*?&&', background_operator)
		self.highlight_match(r'\$\$.*?\$\$', tag_and_trim_operator('highlight', 2))

class App:
	def __init__(self, master, tut_file_name):
		self.master = master
		master.minsize(1000,700)
		master.title('Python Tutorial')
		self.guide = Guide(master)
		self.guide.frame.pack(side=LEFT, fill=Y)
		self.next_button = None
		self.editor = CodeEditor(master)
		self.editor.frame.pack(side=TOP, fill=BOTH, expand=3)
		self.console = Console(master)
		self.console.frame.pack(side=TOP, fill=BOTH, expand=1)

		# Create the buttons
		self.button_frame = Frame(master)
		self.button_frame.pack(side=TOP, fill=X)
		self.button_run = Button(self.button_frame, text='Run', command=self.run_code, state=DISABLED)
		self.button_run.pack(side=RIGHT)
		self.button_save_as = Button(self.button_frame, text='Save As', command=self.save_code_as, state=DISABLED)
		self.button_save_as.pack(side=RIGHT)
		self.button_save = Button(self.button_frame, text='Save', command=self.save_code, state=DISABLED)
		self.button_save.pack(side=RIGHT)
		self.button_open = Button(self.button_frame, text='Open', command=self.load_code, state=DISABLED)
		self.button_open.pack(side=RIGHT)
		self.buttons = [self.button_run, self.button_save_as, self.button_save,
			self.button_open]

		# Details for running programs
		self.filename = ''
		self.proc = None
		self.editing_enabled = False
		self.poll_after_id = None

		master.protocol('WM_DELETE_WINDOW', self.exit)

		# TODO: open from current directory
		# TODO: open from compressed bundle
		tut_file = open(os.path.join('tutorials',tut_file_name))
		self.chapter = Chapter(tut_file.readlines())
		tut_file.close()
		self.context = {
			'guide': self.guide,
			'editor': self.editor,
			'console': self.console,
			'next_step': self.step,
			'app': self,
			'enable_editing': self.enable_editing, # TODO: move into editor class
			'goto': self.goto
		}
		self.local_vars = {}
		self.step()
	
	def exit(self):
		if self.proc is not None and self.proc.poll() is None:
			self.stop_code()
		if self.poll_after_id:
			self.master.after_cancel(self.poll_after_id)
		self.master.destroy()
	
	def enable_editing(self, enable=True):
		if enable == self.editing_enabled:
			return
		self.editing_enabled = enable
		self.editor.set_editable(enable)
		for button in self.buttons:
			button.configure(state = NORMAL if enable else DISABLED)

	def goto(self, label):
		# TODO: do this in a much, much better way.
		# TODO: merge local_vars into context
		self.chapter.lessons[0].goto(self.context, self.local_vars, label)
	
	# TODO: move this elsewhere
	def step(self):
		if self.next_button:
			self.next_button.destroy()
			self.next_button = None
		try:
			self.chapter.step(self.context, self.local_vars)
		except Exception as e:
			self.guide.add_text("""
The tutorial has encountered an error:
%s

Please report this error to club officers.
You might be able to try again, though.
""" % e, 'red')
			# TODO: clean up exceptions.
			self.pause('Try Again')

	def pause(self, text="Next"):
		self.next_button = Button(self.guide.console, text=text, command=self.step)
		self.guide.console.window_create(END, window=self.next_button)

	def load_code(self):
		filename = askopenfilename()
		if filename:
			f = open(filename)
			self.editor.set_text(f.read())
			f.close()
	
	def save_code(self):
		if not self.filename:
			self.save_code_as()
		f = open(self.filename, 'w')
		f.write(self.editor.get_text())
		f.close()
	
	def save_code_as(self):
		# TODO: support filenames for multiple lessons
		self.filename = asksaveasfilename()
		if self.filename:
			self.filename = os.path.abspath(self.filename)
			self.save_code()

	# TODO: move out of App
	def poll_proc(self):
		"Checks to see if the app is done."
		# Now, see if the app is still running
		if self.proc.poll() is None:
			# Keep polling.
			self.poll_after_id = self.master.after(100, self.poll_proc)
		else:
			self.button_run.config(text='Run', command=self.run_code)
	
	def stop_code(self):
		"Stops any running process."
		if self.proc is not None and self.proc.poll() is None:
			self.proc.terminate()
	
	def read_proc_pipe(self):
		if self.proc is None or self.proc.poll() is not None:
			return
		while self.proc.poll() is None:
			# Read the header to find out whether input or output is wanted
			header = self.proc.stdout.read(6)
			# TODO: be better prepared for unexpected shutdowns.
			if header == '#INPUT':
				# Read a line from the console and shove it into stdin.
				line = self.console.readline()
				#sys.stderr.write('Read line .%s.' % line)
				if self.proc.stdin.closed:
					break
				self.proc.stdin.write(line)
				self.proc.stdin.flush()
			elif header == '##DONE':
				# Acknowledge termination, then break.
				# This makes sure we run long enough to get all program input.
				self.proc.stdin.write('\n')
				self.proc.stdin.flush()
				break
			else:
				# The header is the length of the text to write.
				try:
					header = int(header)
					data = ''
					while len(data) < header:
						data += self.proc.stdout.read(header - len(data))
					if os.linesep != '\n':
						data = data.replace(os.linesep, '\n')
					self.console.write(data)
				except ValueError:
					break # Terminate the loop if a bad header happens!
					# TODO: better handling?

	def run_code(self):
		"Runs code using the runner.py helper."
		# Create subprocess
		runner = os.path.join(os.path.dirname(__file__), 'runner.py')
		# TODO: read stderr separately.
		self.proc = Popen([sys.executable, runner], stdin=PIPE, stdout=PIPE)

		# Provide the program
		program = self.editor.get_text()
		self.proc.stdin.write("%d\n%s" % (len(program), program))

		# Change Run button to Stop
		self.button_run.config(text='Stop', command=self.stop_code)

		# Start reading stdout and stderr.
		pipe_reader = Thread(target=self.read_proc_pipe, name='Pipe Reader')
		pipe_reader.daemon = True
		pipe_reader.start()

		# Start checking for subprocess ending
		self.poll_proc()

# Picks a tutorial file from the table of contents.
class Picker(object):
	def __init__(self):
		# Load table of contents
		toc_file = open('table_of_contents.txt')
		self.table_of_contents = [line.strip().split('|') for line in toc_file]
		toc_file.close()

	def show(self):
		# Create dialog
		self.picker = Tk()
		self.picker.minsize(300, 400)
		self.picker.title('Double-click tutorial')
		label = Label(self.picker, justify=LEFT, anchor=W, text='Double-click a tutorial to start!')
		label.pack(side=TOP, fill=X)
		self.listbox = Listbox(self.picker)
		self.listbox.pack(side=TOP, fill=BOTH, expand=TRUE)
		exit_button = Button(self.picker, text='Exit', command=self.picker.destroy)
		exit_button.pack(side=RIGHT)

		# Add chapters
		chapter_number = 1
		for title, tut_file in self.table_of_contents:
			self.listbox.insert(END, "%d. %s" % (chapter_number, title))
			chapter_number += 1
		self.listbox.bind("<Double-Button-1>", self.make_selection)

	def pick_tutorial(self):
		self.show()
		self.tut_file = None
		self.picker.mainloop()
		return self.tut_file

	def make_selection(self, event):
		selections = self.listbox.curselection()
		if len(selections):
			self.tut_file = self.table_of_contents[int(selections[0])][1]
			self.picker.destroy()

# This condition is true if you ran this script from the
# command prompt, instead of including it.
if __name__ == '__main__':
	if UPDATE:
		updater.update()
	os.chdir(os.path.dirname(os.path.realpath(__file__)))
	picker = Picker()
	while True:
		tut_file_name = picker.pick_tutorial()
		if tut_file_name:
			root = Tk()
			app = App(root, tut_file_name)
			root.mainloop()
		else:
			break
